indexResourceHook.test.ts ➔ arrayToIndexedObj   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
1
import fetchMock from "fetch-mock";
2
import { act, renderHook } from "@testing-library/react-hooks";
3
import { FetchError } from "../../helpers/httpRequests";
4
import useResourceIndex, { UNEXPECTED_FORMAT_ERROR } from "./indexResourceHook";
5
import { getId, hasKey, mapToObject } from "../../helpers/queries";
6
7
interface TestResource {
8
  id: number;
9
  name: string;
10
}
11
12
function arrayToIndexedObj(arr) {
13
  return mapToObject(arr, getId);
14
}
15
16
describe("indexResourceHook", () => {
17
  afterEach((): void => {
18
    fetchMock.reset();
19
    fetchMock.restore();
20
  });
21
22
  const endpoint = "https://talent.test/api/test";
23
24
  describe("test initial state and initial fetch", () => {
25
    it("Initially returns no values and a status of 'pending'", () => {
26
      fetchMock.mock("*", [], {
27
        delay: 10,
28
      });
29
      const { result } = renderHook(() => useResourceIndex(endpoint));
30
      expect(result.current.values).toEqual({});
31
      expect(result.current.indexStatus).toEqual("pending");
32
    });
33
    it("If initial value is set returns the initial value but also fetches.", async () => {
34
      const initialValue = [
35
        { id: 1, name: "one" },
36
        { id: 2, name: "two" },
37
      ];
38
      const updatedValue = [
39
        { id: 1, name: "one NEW" },
40
        { id: 2, name: "two NEW" },
41
        { id: 3, name: "three NEW" },
42
      ];
43
      fetchMock.mock("*", updatedValue, { delay: 5 });
44
      const { result, waitFor } = renderHook(() =>
45
        useResourceIndex(endpoint, { initialValue }),
46
      );
47
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
48
      expect(result.current.indexStatus).toEqual("pending");
49
      expect(fetchMock.called()).toBe(true);
50
      await waitFor(() => result.current.indexStatus === "fulfilled");
51
      expect(result.current.values).toEqual(arrayToIndexedObj(updatedValue));
52
      expect(result.current.indexStatus).toEqual("fulfilled");
53
    });
54
    it("If initial value is set and skipInitialRefresh is true, returns initial and does not automatically fetch.", () => {
55
      fetchMock.mock("*", []);
56
      const initialValue = [
57
        { id: 1, name: "one" },
58
        { id: 2, name: "two" },
59
      ];
60
      const { result } = renderHook(() =>
61
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
62
      );
63
      expect(result.current.values).toEqual({
64
        1: initialValue[0],
65
        2: initialValue[1],
66
      });
67
      expect(result.current.indexStatus).toEqual("initial");
68
      expect(fetchMock.called()).toBe(false);
69
    });
70
    it("initialRefreshFinished is false until initial refresh completes", async () => {
71
      fetchMock.mock("*", [], { delay: 5 });
72
      const { result, waitForNextUpdate } = renderHook(() =>
73
        useResourceIndex(endpoint),
74
      );
75
      expect(result.current.initialRefreshFinished).toBe(false);
76
      await waitForNextUpdate();
77
      expect(result.current.initialRefreshFinished).toBe(true);
78
    });
79
    it("initialRefreshFinished becomes true even when initial refresh completes with an error", async () => {
80
      fetchMock.mock("*", 404, { delay: 5 });
81
      const { result, waitForNextUpdate } = renderHook(() =>
82
        useResourceIndex(endpoint),
83
      );
84
      expect(result.current.initialRefreshFinished).toBe(false);
85
      await waitForNextUpdate();
86
      expect(result.current.initialRefreshFinished).toBe(true);
87
    });
88
    it("initialRefreshFinished begins as true when initial fetch is skipped", async () => {
89
      const { result } = renderHook(() =>
90
        useResourceIndex(endpoint, { skipInitialRefresh: true }),
91
      );
92
      expect(result.current.initialRefreshFinished).toBe(true);
93
    });
94
    it("If initial value is set, all entities start with status of 'initial'", () => {
95
      const initialValue = [
96
        { id: 1, name: "one" },
97
        { id: 2, name: "two" },
98
      ];
99
      const { result } = renderHook(() =>
100
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
101
      );
102
      expect(result.current.entityStatus).toEqual({
103
        1: "initial",
104
        2: "initial",
105
      });
106
    });
107
    it("Returns new values and status of 'fulfilled' after initial fetch succeeds", async () => {
108
      const initialValue = [
109
        { id: 1, name: "one" },
110
        { id: 2, name: "two" },
111
      ];
112
      fetchMock.mock("*", initialValue, {
113
        delay: 10,
114
      });
115
116
      const { result, waitFor } = renderHook(() => useResourceIndex(endpoint));
117
118
      expect(result.current.values).toEqual({});
119
      expect(result.current.indexStatus).toEqual("pending");
120
      await waitFor(
121
        () => {
122
          return result.current.indexStatus === "fulfilled";
123
        },
124
        { timeout: 100 },
125
      );
126
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
127
    });
128
    it("parseIndexResponse (if set) transforms values returns by index requests", async () => {
129
      const responseValue = [
130
        { id: 1, name: "one" },
131
        { id: 2, name: "two" },
132
      ];
133
      const parseIndexResponse = (arr) =>
134
        arr.map((x) => ({ ...x, name: `${x.name} PARSED` }));
135
      const expectValue = [
136
        { id: 1, name: "one PARSED" },
137
        { id: 2, name: "two PARSED" },
138
      ];
139
      fetchMock.mock("*", responseValue);
140
      const { result, waitFor } = renderHook(() =>
141
        useResourceIndex(endpoint, { parseIndexResponse }),
142
      );
143
      expect(result.current.values).toEqual({});
144
      expect(result.current.indexStatus).toEqual("pending");
145
      await waitFor(() => result.current.indexStatus === "fulfilled");
146
      expect(result.current.values).toEqual(arrayToIndexedObj(expectValue));
147
    });
148
    it("Initial fetch is a GET request to the provided endpoint", async () => {
149
      fetchMock.getOnce(endpoint, []);
150
      const { result, waitForNextUpdate } = renderHook(() =>
151
        useResourceIndex(endpoint),
152
      );
153
      await waitForNextUpdate();
154
      expect(result.current.indexStatus).toBe("fulfilled");
155
      expect(fetchMock.called()).toBe(true);
156
    });
157
    it("After initial fetch, all values have entityStatus of 'fulfilled'", async () => {
158
      const responseValue = [
159
        { id: 1, name: "one" },
160
        { id: 2, name: "two" },
161
      ];
162
      fetchMock.getOnce(endpoint, responseValue);
163
      const { result, waitFor } = renderHook(() => useResourceIndex(endpoint));
164
      expect(result.current.entityStatus).toEqual({});
165
      await waitFor(() => result.current.indexStatus === "fulfilled");
166
      expect(result.current.entityStatus).toEqual({
167
        1: "fulfilled",
168
        2: "fulfilled",
169
      });
170
    });
171
    it("Returns a 'rejected' status and calls handleError when initial fetch returns a server error", async () => {
172
      fetchMock.once(endpoint, 404);
173
      const handleError = jest.fn();
174
      const { result, waitForNextUpdate } = renderHook(() =>
175
        useResourceIndex(endpoint, { handleError }),
176
      );
177
      expect(result.current.values).toEqual({});
178
      expect(result.current.indexStatus).toEqual("pending");
179
      await waitForNextUpdate();
180
      expect(result.current.values).toEqual({});
181
      expect(result.current.indexStatus).toEqual("rejected");
182
      // handleError was called once on initial fetch.
183
      expect(handleError.mock.calls.length).toBe(1);
184
      // Get error from argument to mocked function.
185
      const initialError = handleError.mock.calls[0][0];
186
      expect(initialError).toBeInstanceOf(FetchError);
187
      expect(initialError.response.status).toBe(404);
188
    });
189
    it("Can store multiple items with same id if keyFn is overridden", async () => {
190
      const initialValue = [
191
        { id: 1, type: "red", name: "one" },
192
        { id: 1, type: "blue", name: "two" },
193
      ];
194
      const responseValue = [
195
        { id: 1, type: "red", name: "one" },
196
        { id: 1, type: "blue", name: "two NEW" },
197
      ];
198
      const keyFn = (item: any) => `${item.type}-${item.id}`;
199
      fetchMock.once("*", responseValue);
200
      const { result, waitForNextUpdate } = renderHook(() =>
201
        useResourceIndex(endpoint, {
202
          initialValue,
203
          keyFn,
204
        }),
205
      );
206
      expect(result.current.values).toEqual({
207
        "red-1": initialValue[0],
208
        "blue-1": initialValue[1],
209
      });
210
      await waitForNextUpdate({ timeout: false });
211
      expect(result.current.values).toEqual({
212
        "red-1": responseValue[0],
213
        "blue-1": responseValue[1],
214
      });
215
    });
216
  });
217
  describe("test refresh callback", () => {
218
    it("refresh() triggers a GET request to endpoint and sets status to 'pending'", async () => {
219
      fetchMock.getOnce(endpoint, []);
220
      const { result, waitForNextUpdate } = renderHook(() =>
221
        useResourceIndex(endpoint, {
222
          skipInitialRefresh: true,
223
        }),
224
      );
225
      expect(result.current.indexStatus).toBe("initial");
226
      await act(async () => {
227
        result.current.refresh();
228
        await waitForNextUpdate({ timeout: false });
229
        expect(result.current.indexStatus).toEqual("pending");
230
      });
231
      expect(fetchMock.called()).toBe(true);
232
    });
233
    it("refresh() returns fetch result and updates hook value", async () => {
234
      const responseValue = [
235
        { id: 1, name: "one" },
236
        { id: 2, name: "two" },
237
      ];
238
      fetchMock.mock(endpoint, responseValue);
239
      const { result } = renderHook(() =>
240
        useResourceIndex(endpoint, {
241
          skipInitialRefresh: true,
242
        }),
243
      );
244
      expect(result.current.values).toEqual({});
245
      expect(result.current.indexStatus).toEqual("initial");
246
      await act(async () => {
247
        const refreshValue = await result.current.refresh();
248
        expect(refreshValue).toEqual(responseValue);
249
      });
250
      expect(result.current.indexStatus).toEqual("fulfilled");
251
      expect(result.current.values).toEqual(arrayToIndexedObj(responseValue));
252
    });
253
    it("refresh() rejects with an error when fetch returns a server error", async () => {
254
      fetchMock.once(endpoint, 404);
255
      const handleError = jest.fn();
256
      const initialValue = [
257
        { id: 1, name: "one" },
258
        { id: 2, name: "two" },
259
      ];
260
      const { result } = renderHook(() =>
261
        useResourceIndex(endpoint, {
262
          initialValue,
263
          skipInitialRefresh: true,
264
          handleError,
265
        }),
266
      );
267
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
268
      expect(result.current.indexStatus).toEqual("initial");
269
      await act(async () => {
270
        await expect(result.current.refresh()).rejects.toBeInstanceOf(
271
          FetchError,
272
        );
273
      });
274
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
275
      expect(result.current.indexStatus).toEqual("rejected");
276
      // handleError was called once on initial fetch.
277
      expect(handleError.mock.calls.length).toBe(1);
278
      // Get error from argument to mocked function.
279
      const initialError = handleError.mock.calls[0][0];
280
      expect(initialError).toBeInstanceOf(FetchError);
281
      expect(initialError.response.status).toBe(404);
282
    });
283
    it("when refresh() returns a server error, handleError is called", async () => {
284
      fetchMock.once("*", 404);
285
      const handleError = jest.fn();
286
      const initialValue = [
287
        { id: 1, name: "one" },
288
        { id: 2, name: "two" },
289
      ];
290
      const { result } = renderHook(() =>
291
        useResourceIndex(endpoint, {
292
          initialValue,
293
          skipInitialRefresh: true,
294
          handleError,
295
        }),
296
      );
297
      await act(async () => {
298
        await expect(result.current.refresh()).rejects.toBeInstanceOf(
299
          FetchError,
300
        );
301
      });
302
      // handleError was called once on initial fetch.
303
      expect(handleError.mock.calls.length).toBe(1);
304
      // Get error from argument to mocked function.
305
      const initialError = handleError.mock.calls[0][0];
306
      expect(initialError).toBeInstanceOf(FetchError);
307
      expect(initialError.response.status).toBe(404);
308
    });
309
    it("when refresh() triggers a Fetch error, handleError is called", async () => {
310
      fetchMock.once("*", { throws: new Error("Failed to fetch") });
311
      const handleError = jest.fn();
312
      const initialValue = [
313
        { id: 1, name: "one" },
314
        { id: 2, name: "two" },
315
      ];
316
      const { result } = renderHook(() =>
317
        useResourceIndex(endpoint, {
318
          initialValue,
319
          skipInitialRefresh: true,
320
          handleError,
321
        }),
322
      );
323
      await act(async () => {
324
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
325
      });
326
      // handleError was called once on initial fetch.
327
      expect(handleError.mock.calls.length).toBe(1);
328
      // Get error from argument to mocked function.
329
      const initialError = handleError.mock.calls[0][0];
330
      expect(initialError.message).toBe("Failed to fetch");
331
    });
332
    it("when refresh() returns invalid JSON, handleError is called", async () => {
333
      fetchMock.once("*", "This is the response");
334
      const handleError = jest.fn();
335
      const initialValue = [
336
        { id: 1, name: "one" },
337
        { id: 2, name: "two" },
338
      ];
339
      const { result } = renderHook(() =>
340
        useResourceIndex(endpoint, {
341
          initialValue,
342
          skipInitialRefresh: true,
343
          handleError,
344
        }),
345
      );
346
      await act(async () => {
347
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
348
      });
349
      // handleError was called once on initial fetch.
350
      expect(handleError.mock.calls.length).toBe(1);
351
      // Get error from argument to mocked function.
352
      const initialError = handleError.mock.calls[0][0];
353
      expect(
354
        initialError.message.startsWith("invalid json response body"),
355
      ).toBe(true);
356
    });
357
    it("when refresh() returns an object with no id, handleError is called", async () => {
358
      fetchMock.once("*", [
359
        { id: 1, name: "one valid JSON" },
360
        { name: "two valid JSON but no id" },
361
      ]);
362
      const handleError = jest.fn();
363
      const initialValue = [
364
        { id: 1, name: "one" },
365
        { id: 2, name: "two" },
366
      ];
367
      const { result } = renderHook(() =>
368
        useResourceIndex(endpoint, {
369
          initialValue,
370
          skipInitialRefresh: true,
371
          handleError,
372
        }),
373
      );
374
      await act(async () => {
375
        await expect(result.current.refresh()).rejects.toBeInstanceOf(Error);
376
      });
377
      // handleError was called once on initial fetch.
378
      expect(handleError.mock.calls.length).toBe(1);
379
      // Get error from argument to mocked function.
380
      const initialError = handleError.mock.calls[0][0];
381
      expect(initialError.message).toBe(UNEXPECTED_FORMAT_ERROR);
382
    });
383
    it("If refresh() is called twice, and one request returns, status remains pending", async () => {
384
      fetchMock.once(endpoint, [], {
385
        delay: 10,
386
      });
387
      // Second call will take longer.
388
      fetchMock.mock("*", [], {
389
        delay: 20,
390
      });
391
      const { result } = renderHook(() =>
392
        useResourceIndex(endpoint, { skipInitialRefresh: true }),
393
      );
394
      await act(async () => {
395
        const refreshPromise1 = result.current.refresh();
396
        const refreshPromise2 = result.current.refresh();
397
        await refreshPromise1;
398
        expect(result.current.indexStatus).toEqual("pending");
399
        await refreshPromise2;
400
        expect(result.current.indexStatus).toEqual("fulfilled");
401
      });
402
    });
403
    it("refresh() sets status on all entities to pending, and to fulfilled when it completes", async () => {
404
      fetchMock.mock("*", [
405
        { id: 1, name: "NEW one" },
406
        { id: 2, name: "NEW two" },
407
      ]);
408
      const initialValue = [
409
        { id: 1, name: "one" },
410
        { id: 2, name: "two" },
411
      ];
412
      const { result, waitForNextUpdate } = renderHook(() =>
413
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
414
      );
415
      await act(async () => {
416
        result.current.refresh();
417
        await waitForNextUpdate({ timeout: false });
418
        expect(result.current.entityStatus).toEqual({
419
          1: "pending",
420
          2: "pending",
421
        });
422
        await waitForNextUpdate();
423
      });
424
      expect(result.current.entityStatus).toEqual({
425
        1: "fulfilled",
426
        2: "fulfilled",
427
      });
428
    });
429
    it("A successful refresh will overwrite a previous error state", async () => {
430
      const responseValue = [
431
        { id: 1, name: "NEW one" },
432
        { id: 2, name: "NEW two" },
433
      ];
434
      // First request fails, second succeeds
435
      fetchMock.once(endpoint, 404);
436
      fetchMock.mock("*", responseValue);
437
      const { result, waitForNextUpdate } = renderHook(() =>
438
        useResourceIndex(endpoint),
439
      );
440
      await waitForNextUpdate();
441
      expect(result.current.indexStatus).toBe("rejected");
442
      await act(async () => {
443
        await result.current.refresh();
444
      });
445
      expect(result.current.indexStatus).toBe("fulfilled");
446
      expect(result.current.values).toEqual(arrayToIndexedObj(responseValue));
447
    });
448
  });
449
  describe("test create callback", () => {
450
    it("create() changes createStatus from initial to pending, and to fulfilled when it completes", async () => {
451
      fetchMock.mock("*", { id: 1, name: "one" });
452
      const { result, waitForNextUpdate } = renderHook(() =>
453
        useResourceIndex<TestResource>(endpoint, { skipInitialRefresh: true }),
454
      );
455
      expect(result.current.createStatus).toBe("initial");
456
      await act(async () => {
457
        result.current.create({ id: 0, name: "one" });
458
        await waitForNextUpdate({ timeout: false });
459
        expect(result.current.createStatus).toBe("pending");
460
        await waitForNextUpdate();
461
        expect(result.current.createStatus).toBe("fulfilled");
462
      });
463
    });
464
    it("create() triggers a POST request to endpoint", async () => {
465
      fetchMock.postOnce(endpoint, { id: 1, name: "one" });
466
      const { result } = renderHook(() =>
467
        useResourceIndex<TestResource>(endpoint, { skipInitialRefresh: true }),
468
      );
469
      await act(async () => {
470
        await result.current.create({ id: 0, name: "one" });
471
      });
472
      expect(fetchMock.called()).toBe(true);
473
    });
474
    it("If resolveCreateEndpoint is set, create() triggers a POST request to the resulting endpoint", async () => {
475
      const resolveCreateEndpoint = (baseEndpoint, newEntity) =>
476
        `${baseEndpoint}/createTest/${newEntity.name}`;
477
      const newEntity = { id: 0, name: "one" };
478
      fetchMock.postOnce(resolveCreateEndpoint(endpoint, newEntity), {
479
        id: 1,
480
        name: "one",
481
      });
482
      const { result } = renderHook(() =>
483
        useResourceIndex<TestResource>(endpoint, {
484
          skipInitialRefresh: true,
485
          resolveCreateEndpoint,
486
        }),
487
      );
488
      await act(async () => {
489
        await result.current.create(newEntity);
490
      });
491
      expect(fetchMock.called()).toBe(true);
492
    });
493
    it("create() returns fetch result and adds to values when it completes", async () => {
494
      const initialValue = [
495
        { id: 1, name: "one" },
496
        { id: 2, name: "two" },
497
      ];
498
      const createValue = { id: 0, name: "three" };
499
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
500
      const responseValue = { id: 3, name: "three" };
501
      fetchMock.postOnce(endpoint, responseValue);
502
      const { result } = renderHook(() =>
503
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
504
      );
505
      await act(async () => {
506
        const createResponseValue = await result.current.create(createValue);
507
        expect(createResponseValue).toEqual(responseValue);
508
      });
509
      // Ensure the new value is the one returned from request, not what we tried to send.
510
      expect(result.current.values[3]).toEqual(responseValue);
511
    });
512
    it("create() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
513
      const initialValue = [
514
        { id: 1, name: "one" },
515
        { id: 2, name: "two" },
516
      ];
517
      const createValue = { id: 3, name: "three" };
518
      fetchMock.postOnce("*", 404);
519
      const { result } = renderHook(() =>
520
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
521
      );
522
      await act(async () => {
523
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
524
          FetchError,
525
        );
526
      });
527
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
528
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
529
      expect(result.current.createStatus).toEqual("rejected");
530
    });
531
    it("when create() returns a server error, handleError is called", async () => {
532
      const initialValue = [
533
        { id: 1, name: "one" },
534
        { id: 2, name: "two" },
535
      ];
536
      const createValue = { id: 3, name: "three" };
537
      fetchMock.postOnce("*", 404);
538
      const handleError = jest.fn();
539
      const { result } = renderHook(() =>
540
        useResourceIndex(endpoint, {
541
          initialValue,
542
          skipInitialRefresh: true,
543
          handleError,
544
        }),
545
      );
546
      await act(async () => {
547
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
548
          FetchError,
549
        );
550
      });
551
552
      // handleError was called once on initial fetch.
553
      expect(handleError.mock.calls.length).toBe(1);
554
      // Get error from argument to mocked function.
555
      const initialError = handleError.mock.calls[0][0];
556
      expect(initialError).toBeInstanceOf(FetchError);
557
      expect(initialError.response.status).toBe(404);
558
    });
559
    it("when create() triggers a Fetch error, it is handled correctly", async () => {
560
      const initialValue = [
561
        { id: 1, name: "one" },
562
        { id: 2, name: "two" },
563
      ];
564
      const createValue = { id: 3, name: "three" };
565
      fetchMock.postOnce("*", { throws: new Error("Failed to fetch") });
566
      const handleError = jest.fn();
567
      const { result } = renderHook(() =>
568
        useResourceIndex(endpoint, {
569
          initialValue,
570
          skipInitialRefresh: true,
571
          handleError,
572
        }),
573
      );
574
      await act(async () => {
575
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
576
          Error,
577
        );
578
      });
579
580
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
581
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
582
      expect(result.current.createStatus).toEqual("rejected");
583
584
      // handleError was called once on initial fetch.
585
      expect(handleError.mock.calls.length).toBe(1);
586
      // Get error from argument to mocked function.
587
      const initialError = handleError.mock.calls[0][0];
588
      expect(initialError).toBeInstanceOf(Error);
589
      expect(initialError.message).toBe("Failed to fetch");
590
    });
591
    it("when create() returns invalid JSON, error is handled correctly", async () => {
592
      const initialValue = [
593
        { id: 1, name: "one" },
594
        { id: 2, name: "two" },
595
      ];
596
      const createValue = { id: 3, name: "three" };
597
      fetchMock.postOnce("*", "This response is not JSON");
598
      const handleError = jest.fn();
599
      const { result } = renderHook(() =>
600
        useResourceIndex(endpoint, {
601
          initialValue,
602
          skipInitialRefresh: true,
603
          handleError,
604
        }),
605
      );
606
      await act(async () => {
607
        await expect(result.current.create(createValue)).rejects.toBeInstanceOf(
608
          Error,
609
        );
610
      });
611
612
      // Values should be unchanged from initial values because create request failed, though createStatus should be different.
613
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
614
      expect(result.current.createStatus).toEqual("rejected");
615
616
      // handleError was called once on initial fetch.
617
      expect(handleError.mock.calls.length).toBe(1);
618
      // Get error from argument to mocked function.
619
      const initialError = handleError.mock.calls[0][0];
620
      expect(initialError).toBeInstanceOf(Error);
621
      expect(
622
        initialError.message.startsWith("invalid json response body"),
623
      ).toBe(true);
624
    });
625
    it("when create() returns an object with an id which already exists in values, that object is updated", async () => {
626
      const initialValue = [
627
        { id: 1, name: "one" },
628
        { id: 2, name: "two" },
629
      ];
630
      const duplicateValue = { id: 2, name: "UPDATED two" };
631
      fetchMock.postOnce("*", duplicateValue);
632
      const { result } = renderHook(() =>
633
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
634
      );
635
      await act(async () => {
636
        await result.current.create(duplicateValue);
637
      });
638
      expect(result.current.values[2]).toEqual(duplicateValue);
639
      expect(result.current.createStatus).toEqual("fulfilled");
640
    });
641
    it("If multiple create requests are started, createStatus remains pending until all are complete", async () => {
642
      const createOne = { id: 0, name: "one" };
643
      const createTwo = { id: 0, name: "two" };
644
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
645
      fetchMock.postOnce(endpoint, createOne);
646
      fetchMock.post("*", createTwo, { delay: 5 });
647
      const { result } = renderHook(() =>
648
        useResourceIndex<TestResource>(endpoint, { skipInitialRefresh: true }),
649
      );
650
      await act(async () => {
651
        const createPromise1 = result.current.create(createOne);
652
        const createPromise2 = result.current.create(createTwo);
653
        await createPromise1;
654
        expect(result.current.values).toEqual(arrayToIndexedObj([createOne]));
655
        expect(result.current.createStatus).toEqual("pending");
656
        await createPromise2;
657
        expect(result.current.values).toEqual(
658
          arrayToIndexedObj([createOne, createTwo]),
659
        );
660
        expect(result.current.createStatus).toEqual("fulfilled");
661
      });
662
    });
663
    it("A successful create() will overwrite a previous error state", async () => {
664
      const createOne = { id: 0, name: "one" };
665
      const createTwo = { id: 0, name: "two" };
666
      // NOTE: The value returned from server may be slightly different from what we send it - likely a different id.
667
      fetchMock.postOnce(endpoint, 404);
668
      fetchMock.post("*", createTwo, { delay: 5 });
669
      const { result } = renderHook(() =>
670
        useResourceIndex<TestResource>(endpoint, { skipInitialRefresh: true }),
671
      );
672
      await act(async () => {
673
        await expect(result.current.create(createOne)).rejects.toThrow();
674
        expect(result.current.values).toEqual({});
675
        expect(result.current.createStatus).toEqual("rejected");
676
        await result.current.create(createTwo);
677
        expect(result.current.values).toEqual(arrayToIndexedObj([createTwo]));
678
        expect(result.current.createStatus).toEqual("fulfilled");
679
      });
680
    });
681
    it("Can store new item at correct key if keyFn is overridden", async () => {
682
      const createValue = { id: 1, type: "red", name: "one" };
683
      const keyFn = (item: any) => `${item.type}-${item.id}`;
684
      fetchMock.once("*", createValue);
685
      const { result } = renderHook(() =>
686
        useResourceIndex(endpoint, { skipInitialRefresh: true, keyFn }),
687
      );
688
      expect(result.current.values).toEqual({});
689
      await act(async () => {
690
        await result.current.create(createValue);
691
      });
692
      expect(result.current.values).toEqual({
693
        "red-1": createValue,
694
      });
695
    });
696
  });
697
  describe("test update callback", () => {
698
    it("update() changes status of specific entity to pending, and then fulfilled, without affecting status of others", async () => {
699
      const initialValue = [
700
        { id: 1, name: "one" },
701
        { id: 2, name: "two" },
702
      ];
703
      const updateValue = { id: 2, name: "UPDATE two" };
704
      fetchMock.mock("*", updateValue);
705
      const { result, waitForNextUpdate } = renderHook(() =>
706
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
707
      );
708
      expect(result.current.entityStatus[2]).toEqual("initial");
709
      expect(result.current.entityStatus[1]).toEqual("initial");
710
      expect(result.current.indexStatus).toEqual("initial");
711
      await act(async () => {
712
        result.current.update(updateValue);
713
        await waitForNextUpdate({ timeout: false });
714
        expect(result.current.entityStatus[2]).toEqual("pending");
715
        expect(result.current.entityStatus[1]).toEqual("initial");
716
        expect(result.current.indexStatus).toEqual("initial");
717
        await waitForNextUpdate();
718
        expect(result.current.entityStatus[2]).toEqual("fulfilled");
719
        expect(result.current.entityStatus[1]).toEqual("initial");
720
        expect(result.current.indexStatus).toEqual("initial");
721
      });
722
    });
723
    it("update() triggers a PUT request to endpoint (with id appended)", async () => {
724
      const initialValue = [
725
        { id: 1, name: "one" },
726
        { id: 2, name: "two" },
727
      ];
728
      const updateValue = { id: 2, name: "UPDATE two" };
729
      fetchMock.putOnce(`${endpoint}/${updateValue.id}`, updateValue);
730
      const { result } = renderHook(() =>
731
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
732
      );
733
      await act(async () => {
734
        await result.current.update(updateValue);
735
      });
736
      expect(fetchMock.called()).toBe(true);
737
    });
738
    it("If resolveEntityEndpoint is set, update() triggers a PUT request to resulting endpoint", async () => {
739
      const initialValue = [
740
        { id: 1, name: "one" },
741
        { id: 2, name: "two" },
742
      ];
743
      const updateValue = { id: 2, name: "UPDATE two" };
744
      const resolveEntityEndpoint = (baseEndpoint, entity) =>
745
        `${baseEndpoint}/resolveEntityTest/${entity.id}`;
746
      fetchMock.putOnce(
747
        resolveEntityEndpoint(endpoint, updateValue),
748
        updateValue,
749
      );
750
      const { result } = renderHook(() =>
751
        useResourceIndex(endpoint, {
752
          initialValue,
753
          skipInitialRefresh: true,
754
          resolveEntityEndpoint,
755
        }),
756
      );
757
      await act(async () => {
758
        await result.current.update(updateValue);
759
      });
760
      expect(fetchMock.called()).toBe(true);
761
    });
762
    it("update() returns fetch result and updates values when it completes", async () => {
763
      const initialValue = [
764
        { id: 1, name: "one" },
765
        { id: 2, name: "two" },
766
      ];
767
      const updateValue = { id: 2, name: "UPDATE two" };
768
      // NOTE: The value returned from server may be slightly different from what we send it.
769
      const responseValue = { id: 2, name: "UPDATE two RETURNED" };
770
      fetchMock.putOnce("*", responseValue);
771
      const { result } = renderHook(() =>
772
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
773
      );
774
      await act(async () => {
775
        const updateResponseValue = await result.current.update(updateValue);
776
        expect(updateResponseValue).toEqual(responseValue);
777
      });
778
      // Ensure the new value is the one returned from request, not what we tried to send.
779
      expect(result.current.values[2]).toEqual(responseValue);
780
    });
781
    it("update() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
782
      const initialValue = [
783
        { id: 1, name: "one" },
784
        { id: 2, name: "two" },
785
      ];
786
      const updateValue = { id: 2, name: "UPDATE two" };
787
      fetchMock.putOnce("*", 404);
788
      const { result } = renderHook(() =>
789
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
790
      );
791
      await act(async () => {
792
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
793
          FetchError,
794
        );
795
      });
796
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
797
      expect(result.current.entityStatus[2]).toEqual("rejected");
798
    });
799
    it("when update() returns a server error, handleError is called", async () => {
800
      fetchMock.putOnce("*", 404);
801
      const handleError = jest.fn();
802
      const initialValue = [
803
        { id: 1, name: "one" },
804
        { id: 2, name: "two" },
805
      ];
806
      const updateValue = { id: 2, name: "UPDATE two" };
807
      const { result } = renderHook(() =>
808
        useResourceIndex(endpoint, {
809
          initialValue,
810
          skipInitialRefresh: true,
811
          handleError,
812
        }),
813
      );
814
      await act(async () => {
815
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
816
          FetchError,
817
        );
818
      });
819
      // handleError was called once on initial fetch.
820
      expect(handleError.mock.calls.length).toBe(1);
821
      // Get error from argument to mocked function.
822
      const initialError = handleError.mock.calls[0][0];
823
      expect(initialError).toBeInstanceOf(FetchError);
824
      expect(initialError.response.status).toBe(404);
825
    });
826
    it("when update() triggers a Fetch error, error is handled correctly", async () => {
827
      fetchMock.putOnce("*", { throws: new Error("Failed to fetch") });
828
      const handleError = jest.fn();
829
      const initialValue = [
830
        { id: 1, name: "one" },
831
        { id: 2, name: "two" },
832
      ];
833
      const updateValue = { id: 2, name: "UPDATE two" };
834
      const { result } = renderHook(() =>
835
        useResourceIndex(endpoint, {
836
          initialValue,
837
          skipInitialRefresh: true,
838
          handleError,
839
        }),
840
      );
841
      await act(async () => {
842
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
843
          Error,
844
        );
845
      });
846
      // handleError was called once on initial fetch.
847
      expect(handleError.mock.calls.length).toBe(1);
848
      // Get error from argument to mocked function.
849
      const initialError = handleError.mock.calls[0][0];
850
      expect(initialError.message).toBe("Failed to fetch");
851
    });
852
    it("when update() is called with an object whose id doesn't exist yet, state is unchanged until update request is fulfilled", async () => {
853
      const initialValue = [
854
        { id: 1, name: "one" },
855
        { id: 2, name: "two" },
856
      ];
857
      const newValue = { id: 3, name: "three" };
858
      fetchMock.putOnce("*", newValue);
859
      const { result, waitForNextUpdate } = renderHook(() =>
860
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
861
      );
862
      await act(async () => {
863
        result.current.update(newValue);
864
        await waitForNextUpdate({ timeout: false });
865
        expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
866
        await waitForNextUpdate();
867
        expect(result.current.values[3]).toEqual(newValue);
868
        expect(result.current.entityStatus[3]).toEqual("fulfilled");
869
      });
870
    });
871
    it("when update() returns invalid JSON, error is handled correctly", async () => {
872
      fetchMock.putOnce("*", "This is the response");
873
      const handleError = jest.fn();
874
      const initialValue = [
875
        { id: 1, name: "one" },
876
        { id: 2, name: "two" },
877
      ];
878
      const updateValue = { id: 2, name: "UPDATE two" };
879
      const { result } = renderHook(() =>
880
        useResourceIndex(endpoint, {
881
          initialValue,
882
          skipInitialRefresh: true,
883
          handleError,
884
        }),
885
      );
886
      await act(async () => {
887
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
888
          Error,
889
        );
890
      });
891
      // handleError was called once on initial fetch.
892
      expect(handleError.mock.calls.length).toBe(1);
893
      // Get error from argument to mocked function.
894
      const initialError = handleError.mock.calls[0][0];
895
      expect(
896
        initialError.message.startsWith("invalid json response body"),
897
      ).toBe(true);
898
    });
899
    it("when update() returns an object with no id, error is handled correctly", async () => {
900
      fetchMock.putOnce("*", { name: "This is valid JSON now" });
901
      const handleError = jest.fn();
902
      const initialValue = [
903
        { id: 1, name: "one" },
904
        { id: 2, name: "two" },
905
      ];
906
      const updateValue = { id: 2, name: "UPDATE two" };
907
      const { result } = renderHook(() =>
908
        useResourceIndex(endpoint, {
909
          initialValue,
910
          skipInitialRefresh: true,
911
          handleError,
912
        }),
913
      );
914
      await act(async () => {
915
        await expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
916
          Error,
917
        );
918
      });
919
      // handleError was called once on initial fetch.
920
      expect(handleError.mock.calls.length).toBe(1);
921
      // Get error from argument to mocked function.
922
      const initialError = handleError.mock.calls[0][0];
923
      expect(initialError.message).toBe(UNEXPECTED_FORMAT_ERROR);
924
    });
925
    it("If multiple update requests are started, status remains pending until all are complete", async () => {
926
      const response1 = { id: 2, name: "UPDATE two v1" };
927
      const response2 = { id: 2, name: "UPDATE two v2" };
928
      fetchMock.once(`${endpoint}/2`, response1);
929
      // Second call will take longer.
930
      fetchMock.mock("*", response2, {
931
        delay: 5,
932
      });
933
      const initialValue = [
934
        { id: 1, name: "one" },
935
        { id: 2, name: "two" },
936
      ];
937
      const updateValue = { id: 2, name: "UPDATE two" };
938
      const { result } = renderHook(() =>
939
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
940
      );
941
      await act(async () => {
942
        const updatePromise1 = result.current.update(updateValue);
943
        const updatePromise2 = result.current.update(updateValue);
944
        await updatePromise1;
945
        expect(result.current.values[2]).toEqual(response1);
946
        expect(result.current.entityStatus[2]).toEqual("pending");
947
        await updatePromise2;
948
        expect(result.current.values[2]).toEqual(response2);
949
        expect(result.current.entityStatus[2]).toEqual("fulfilled");
950
      });
951
    });
952
    it("A successful update() will overwrite a previous error state", async () => {
953
      const initialValue = [
954
        { id: 1, name: "one" },
955
        { id: 2, name: "two" },
956
      ];
957
      const updateValue = { id: 2, name: "UPDATE two" };
958
      // First request fails, second succeeds
959
      fetchMock.putOnce(`${endpoint}/${updateValue.id}`, 404);
960
      fetchMock.mock("*", updateValue);
961
      const { result } = renderHook(() =>
962
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
963
      );
964
      await act(async () => {
965
        expect(result.current.update(updateValue)).rejects.toBeInstanceOf(
966
          FetchError,
967
        );
968
      });
969
      expect(result.current.entityStatus[2]).toBe("rejected");
970
      await act(async () => {
971
        await result.current.update(updateValue);
972
      });
973
      expect(result.current.entityStatus[2]).toBe("fulfilled");
974
      expect(result.current.values[2]).toEqual(updateValue);
975
    });
976
    it("Will update the correct value if keyFn is overridden", async () => {
977
      const initialValue = [
978
        { id: 1, type: "red", name: "one" },
979
        { id: 1, type: "blue", name: "two" },
980
      ];
981
      const responseValue = { id: 1, type: "blue", name: "NEW one" };
982
      const keyFn = (item: any) => `${item.type}-${item.id}`;
983
      fetchMock.once("*", responseValue);
984
      const { result } = renderHook(() =>
985
        useResourceIndex(endpoint, {
986
          initialValue,
987
          skipInitialRefresh: true,
988
          keyFn,
989
        }),
990
      );
991
      expect(result.current.values).toEqual({
992
        "red-1": initialValue[0],
993
        "blue-1": initialValue[1],
994
      });
995
      await act(async () => {
996
        await result.current.update(responseValue);
997
      });
998
      expect(result.current.values).toEqual({
999
        "red-1": initialValue[0],
1000
        "blue-1": responseValue,
1001
      });
1002
    });
1003
  });
1004
  describe("test deleteResource callback", () => {
1005
    it("deleteResource() changes status of specific entity from initial to pending. Status and value are removed when it completes.", async () => {
1006
      const initialValue = [
1007
        { id: 1, name: "one" },
1008
        { id: 2, name: "two" },
1009
      ];
1010
      fetchMock.mock("*", 200, { delay: 5 });
1011
      const { result, waitForNextUpdate } = renderHook(() =>
1012
        useResourceIndex(endpoint, { initialValue, skipInitialRefresh: true }),
1013
      );
1014
      expect(result.current.entityStatus[2]).toEqual("initial");
1015
      expect(result.current.entityStatus[1]).toEqual("initial");
1016
      expect(result.current.indexStatus).toEqual("initial");
1017
      await act(async () => {
1018
        result.current.deleteResource({ id: 2, name: "two" });
1019
        await waitForNextUpdate();
1020
        expect(result.current.entityStatus[2]).toEqual("pending");
1021
        expect(result.current.entityStatus[1]).toEqual("initial");
1022
        expect(result.current.indexStatus).toEqual("initial");
1023
        await waitForNextUpdate();
1024
        expect(hasKey(result.current.entityStatus, 2)).toBe(false);
1025
        expect(hasKey(result.current.values, 2)).toBe(false);
1026
        expect(result.current.entityStatus[1]).toEqual("initial");
1027
        expect(result.current.indexStatus).toEqual("initial");
1028
      });
1029
    });
1030
    it("deleteResource() triggers a DELETE request to endpoint with id appended", async () => {
1031
      const initialValue = [
1032
        { id: 1, name: "one" },
1033
        { id: 2, name: "two" },
1034
      ];
1035
      fetchMock.deleteOnce(`${endpoint}/2`, 200);
1036
      const { result } = renderHook(() =>
1037
        useResourceIndex(endpoint, { initialValue }),
1038
      );
1039
      await act(async () => {
1040
        await result.current.deleteResource({ id: 2, name: "two" });
1041
      });
1042
      expect(fetchMock.called()).toBe(true);
1043
    });
1044
    it("If resolveEntityEndpoint is set, deleteResource() triggers a DELETE request to resulting endpoint", async () => {
1045
      const initialValue = [
1046
        { id: 1, name: "one" },
1047
        { id: 2, name: "two" },
1048
      ];
1049
      const resolveEntityEndpoint = (baseEndpoint, entity) =>
1050
        `${baseEndpoint}/resolveEntityTest/${entity.id}`;
1051
      fetchMock.deleteOnce(
1052
        resolveEntityEndpoint(endpoint, { id: 2, name: "two" }),
1053
        200,
1054
      );
1055
      const { result } = renderHook(() =>
1056
        useResourceIndex(endpoint, { initialValue, resolveEntityEndpoint }),
1057
      );
1058
      await act(async () => {
1059
        await result.current.deleteResource({ id: 2, name: "two" });
1060
      });
1061
      expect(fetchMock.called()).toBe(true);
1062
    });
1063
    it("deleteResource() resolves when entity is removed from values", async () => {
1064
      const initialValue = [
1065
        { id: 1, name: "one" },
1066
        { id: 2, name: "two" },
1067
      ];
1068
      fetchMock.mock("*", 200, { delay: 5 });
1069
      const { result } = renderHook(() =>
1070
        useResourceIndex(endpoint, { initialValue }),
1071
      );
1072
      await act(async () => {
1073
        await result.current.deleteResource({ id: 2, name: "two" });
1074
        expect(hasKey(result.current.entityStatus, 2)).toBe(false);
1075
        expect(hasKey(result.current.values, 2)).toBe(false);
1076
      });
1077
    });
1078
    it("deleteResource() rejects with an error (leaving values unchanged) when fetch returns a server error", async () => {
1079
      const initialValue = [
1080
        { id: 1, name: "one" },
1081
        { id: 2, name: "two" },
1082
      ];
1083
      fetchMock.deleteOnce("*", 404);
1084
      const { result } = renderHook(() =>
1085
        useResourceIndex(endpoint, { initialValue }),
1086
      );
1087
      await act(async () => {
1088
        await expect(
1089
          result.current.deleteResource({ id: 2, name: "two" }),
1090
        ).rejects.toBeInstanceOf(FetchError);
1091
      });
1092
      expect(result.current.values).toEqual(arrayToIndexedObj(initialValue));
1093
      expect(result.current.entityStatus[2]).toEqual("rejected");
1094
    });
1095
    it("when deleteResource() returns a server error, handleError is called", async () => {
1096
      fetchMock.deleteOnce("*", 500);
1097
      const handleError = jest.fn();
1098
      const initialValue = [
1099
        { id: 1, name: "one" },
1100
        { id: 2, name: "two" },
1101
      ];
1102
      const { result } = renderHook(() =>
1103
        useResourceIndex(endpoint, {
1104
          initialValue,
1105
          skipInitialRefresh: true,
1106
          handleError,
1107
        }),
1108
      );
1109
      await act(async () => {
1110
        await expect(
1111
          result.current.deleteResource({ id: 2, name: "two" }),
1112
        ).rejects.toBeInstanceOf(FetchError);
1113
      });
1114
      // handleError was called once on initial fetch.
1115
      expect(handleError.mock.calls.length).toBe(1);
1116
      // Get error from argument to mocked function.
1117
      const initialError = handleError.mock.calls[0][0];
1118
      expect(initialError).toBeInstanceOf(FetchError);
1119
      expect(initialError.response.status).toBe(500);
1120
    });
1121
    it("when deleteResource() triggers a Fetch error, handleError is called", async () => {
1122
      fetchMock.deleteOnce("*", { throws: new Error("Failed to fetch") });
1123
      const handleError = jest.fn();
1124
      const initialValue = [
1125
        { id: 1, name: "one" },
1126
        { id: 2, name: "two" },
1127
      ];
1128
      const { result } = renderHook(() =>
1129
        useResourceIndex(endpoint, {
1130
          initialValue,
1131
          skipInitialRefresh: true,
1132
          handleError,
1133
        }),
1134
      );
1135
      await act(async () => {
1136
        await expect(
1137
          result.current.deleteResource({ id: 2, name: "two" }),
1138
        ).rejects.toBeInstanceOf(Error);
1139
      });
1140
      // handleError was called once on initial fetch.
1141
      expect(handleError.mock.calls.length).toBe(1);
1142
      // Get error from argument to mocked function.
1143
      const initialError = handleError.mock.calls[0][0];
1144
      expect(initialError).toBeInstanceOf(Error);
1145
      expect(initialError).toEqual(new Error("Failed to fetch"));
1146
    });
1147
    it("if deleteResource() is called multiple times on the same id, status should remain pending until one succeeds. Later callbacks should not change values.", async () => {
1148
      const initialValue = [
1149
        { id: 1, name: "one" },
1150
        { id: 2, name: "two" },
1151
      ];
1152
      // First call will fail, subsequent calls will succeed.
1153
      fetchMock.deleteOnce(`${endpoint}/2`, 500);
1154
      fetchMock.delete("*", 200, { delay: 5 });
1155
      const { result } = renderHook(() =>
1156
        useResourceIndex(endpoint, { initialValue }),
1157
      );
1158
      await act(async () => {
1159
        const deletePromise1 = result.current.deleteResource({
1160
          id: 2,
1161
          name: "two",
1162
        });
1163
        const deletePromise2 = result.current.deleteResource({
1164
          id: 2,
1165
          name: "two",
1166
        });
1167
        const deletePromise3 = result.current.deleteResource({
1168
          id: 2,
1169
          name: "two",
1170
        });
1171
        await expect(deletePromise1).rejects.toThrow();
1172
        expect(result.current.entityStatus[2]).toBe("pending");
1173
        await deletePromise2;
1174
        const expectValue = { 1: { id: 1, name: "one" } };
1175
        expect(result.current.values).toEqual(expectValue);
1176
        await deletePromise3;
1177
        expect(result.current.values).toEqual(expectValue);
1178
      });
1179
    });
1180
  });
1181
});
1182